diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 1fefb22..ff777f8 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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.
diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs
index 1ee8750..25fa2a9 100644
--- a/src/AcDream.App/Rendering/RetailChaseCamera.cs
+++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs
@@ -83,14 +83,28 @@ public sealed class RetailChaseCamera : ICamera
/// , and reflect
/// the new state.
///
- 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.
///
- /// 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 target_status &
- /// ALIGN_WITH_PLANE path with the contact-plane branch
- /// collapsed into the flat fallback.
+ /// Pick the heading vector that drives the camera basis. Mirrors
+ /// retail's CameraManager::UpdateCamera ALIGN_WITH_PLANE
+ /// path (decomp acclient_2013_pseudo_c.txt:95644-95795):
+ ///
+ /// - Base heading is the player's facing
+ /// direction in world space — (cos yaw, sin yaw, 0)
+ /// — not the velocity vector. Velocity only gates whether
+ /// slope-alignment fires.
+ /// - If 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.
+ /// - Otherwise project the base heading onto
+ /// the plane perpendicular to a surface normal:
+ /// 's Normal when
+ /// grounded (slope-aligned), world (0, 0, 1) when
+ /// airborne (which is a no-op since the base is already
+ /// horizontal).
+ /// - Normalize. If the projection collapsed
+ /// (heading parallel to normal), fall back to the unprojected
+ /// base.
+ ///
///
- internal static Vector3 ComputeHeading(Vector3 avgVelocity, float yaw, bool alignToSlope)
+ /// 5-frame averaged player velocity in world space.
+ /// Player facing yaw + any orbit offset, radians.
+ /// Player's transient_state & 1 — does describe a valid contact plane?
+ /// Player's current contact plane normal in world space; ignored when is false.
+ /// User-tunable; when false skips the projection and returns the flat facing direction.
+ 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);
}
///
diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
index 4a1ef00..ed7bef4 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
@@ -16,7 +16,10 @@ public class RetailChaseCameraTests
var avgVel = Vector3.Zero;
float yaw = MathF.PI / 4f; // 45°
- var h = RetailChaseCamera.ComputeHeading(avgVel, yaw, alignToSlope: true);
+ var h = RetailChaseCamera.ComputeHeading(
+ avgVel, yaw,
+ isOnGround: true, contactPlaneNormal: Vector3.UnitZ,
+ alignToSlope: true);
Assert.Equal(MathF.Cos(yaw), h.X, 5);
Assert.Equal(MathF.Sin(yaw), h.Y, 5);
@@ -24,30 +27,87 @@ public class RetailChaseCameraTests
}
[Fact]
- public void Heading_MovingHorizontal_MatchesNormalizedVelocity()
+ public void Heading_MovingOnFlatGround_HeadingIsHorizontalFacing()
{
+ // Player moving forward (yaw=0 = +X), on flat ground. Heading
+ // should be the yaw vector — the projection onto (0,0,1)-normal
+ // plane is a no-op since the base is already horizontal.
var avgVel = new Vector3(3f, 0f, 0f);
- var h = RetailChaseCamera.ComputeHeading(avgVel, yaw: 0f, alignToSlope: true);
+ var h = RetailChaseCamera.ComputeHeading(
+ avgVel, yaw: 0f,
+ isOnGround: true, contactPlaneNormal: Vector3.UnitZ,
+ alignToSlope: true);
Assert.Equal(1f, h.X, 5);
Assert.Equal(0f, h.Y, 5);
Assert.Equal(0f, h.Z, 5);
}
[Fact]
- public void Heading_MovingUphill_HasPositiveZ()
+ public void Heading_OnUphillSlope_TiltsWithSlope()
{
- var avgVel = new Vector3(1f, 0f, 0.5f);
- var h = RetailChaseCamera.ComputeHeading(avgVel, yaw: 0f, alignToSlope: true);
- Assert.True(h.Z > 0f, $"expected positive Z component, got {h.Z}");
+ // Player facing +Y (yaw=π/2), walking up a slope rising in +Y.
+ // Slope normal tilts back-up: (0, -0.5, 0.866) (30° rise).
+ // Projection of (0,1,0) onto plane perpendicular to (0,-0.5,0.866):
+ // dot = 1*(-0.5) = -0.5
+ // projected = (0,1,0) - (0,-0.5,0.866)*(-0.5) = (0, 0.75, 0.433)
+ // normalized → (0, 0.866, 0.5) — slope-aligned heading with +Z tilt.
+ var avgVel = new Vector3(0f, 3f, 1.5f); // moving up the slope
+ var normal = new Vector3(0f, -0.5f, 0.866f);
+ var h = RetailChaseCamera.ComputeHeading(
+ avgVel, yaw: MathF.PI / 2f,
+ isOnGround: true, contactPlaneNormal: normal,
+ alignToSlope: true);
+ Assert.True(h.Z > 0.4f, $"expected slope-aligned +Z tilt, got Z={h.Z}");
+ Assert.Equal(1f, h.Length(), 4);
}
[Fact]
- public void Heading_SlopeAlignDisabled_IgnoresVelocity()
+ public void Heading_AirborneJumpingStraightUp_StaysHorizontal()
{
- var avgVel = new Vector3(0f, 0f, 1f); // pure upward; would dominate if slope-align were on
- float yaw = 0f;
+ // Player standing still, then jumps straight up. avgVel.xy is
+ // zero, the horizontal-velocity gate fires → returns the base
+ // facing direction. The vertical-velocity component is ignored.
+ // This is THE bug the contact-plane fix prevents: in the old
+ // code, normalize((0,0,5)) → (0,0,1) → camera basis tilted up.
+ var avgVel = new Vector3(0f, 0f, 5f);
+ var h = RetailChaseCamera.ComputeHeading(
+ avgVel, yaw: 0f,
+ isOnGround: false, contactPlaneNormal: Vector3.Zero,
+ alignToSlope: true);
+ Assert.Equal(1f, h.X, 5);
+ Assert.Equal(0f, h.Y, 5);
+ Assert.Equal(0f, h.Z, 5);
+ }
- var h = RetailChaseCamera.ComputeHeading(avgVel, yaw, alignToSlope: false);
+ [Fact]
+ public void Heading_AirborneRunningJump_StaysHorizontal()
+ {
+ // Running jump: horizontal velocity nonzero, vertical also
+ // nonzero. Airborne path projects onto world up — strips Z
+ // from the (already horizontal) base heading, no-op. Camera
+ // basis stays horizontal even though player is rising.
+ var avgVel = new Vector3(3f, 0f, 4f);
+ var h = RetailChaseCamera.ComputeHeading(
+ avgVel, yaw: 0f,
+ isOnGround: false, contactPlaneNormal: Vector3.Zero,
+ alignToSlope: true);
+ Assert.Equal(1f, h.X, 5);
+ Assert.Equal(0f, h.Y, 5);
+ Assert.Equal(0f, h.Z, 5);
+ }
+
+ [Fact]
+ public void Heading_SlopeAlignDisabled_IgnoresVelocityAndContactPlane()
+ {
+ // Pure-vertical velocity + a tilted contact normal — neither
+ // should affect the heading when alignToSlope is off.
+ var avgVel = new Vector3(0f, 0f, 1f);
+ var tiltedNormal = new Vector3(0f, -0.5f, 0.866f);
+
+ var h = RetailChaseCamera.ComputeHeading(
+ avgVel, yaw: 0f,
+ isOnGround: true, contactPlaneNormal: tiltedNormal,
+ alignToSlope: false);
Assert.Equal(1f, h.X, 5); // (cos 0, sin 0, 0) = (1, 0, 0)
Assert.Equal(0f, h.Y, 5);
@@ -280,10 +340,12 @@ public class RetailChaseCameraTests
CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec
cam.Update(
- playerPosition: new Vector3(10f, 20f, 30f),
- playerYaw: 0f, // forward = +X
- playerVelocity: Vector3.Zero,
- dt: 1f / 60f);
+ playerPosition: new Vector3(10f, 20f, 30f),
+ playerYaw: 0f, // forward = +X
+ playerVelocity: Vector3.Zero,
+ isOnGround: true,
+ contactPlaneNormal: Vector3.UnitZ, // flat
+ dt: 1f / 60f);
// Expected target eye:
// pivot = (10, 20, 30+1.5=31.5)
@@ -317,7 +379,8 @@ public class RetailChaseCameraTests
CameraDiagnostics.RotationStiffness = 0.45f;
// First update at origin: dampedEye = (-5, 0, 1.5).
- cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
+ cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
+ isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
var firstEye = cam.Position;
// Teleport the player one frame later. Target eye now at (10-5, 0, 1.5) = (5, 0, 1.5).
@@ -326,7 +389,8 @@ public class RetailChaseCameraTests
// = (-5,0,1.5) + 0.075 * ((5,0,1.5) - (-5,0,1.5))
// = (-5,0,1.5) + 0.075 * (10,0,0)
// = (-4.25, 0, 1.5)
- cam.Update(new Vector3(10f, 0f, 0f), playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
+ cam.Update(new Vector3(10f, 0f, 0f), playerYaw: 0f, playerVelocity: Vector3.Zero,
+ isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
Assert.Equal(-4.25f, cam.Position.X, 3);
Assert.Equal(0f, cam.Position.Y, 4);
@@ -350,7 +414,8 @@ public class RetailChaseCameraTests
CameraDiagnostics.AlignToSlope = false;
// Far from pivot — translucency should be 0.
- cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
+ cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
+ isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
Assert.Equal(0f, cam.PlayerTranslucency, 5);
}
finally