From b7e954e50bdc1be5cf32dafbe53a0c78b19aaa5d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 09:32:50 +0200 Subject: [PATCH] fix(camera): retail-faithful jump-tracking via contact-plane projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 20 +++- .../Rendering/RetailChaseCamera.cs | 101 +++++++++++++++--- .../Rendering/RetailChaseCameraTests.cs | 101 ++++++++++++++---- 3 files changed, 188 insertions(+), 34 deletions(-) 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